今天要介紹的是 Proxy 模式,這也是 GoF 在書中提及的設計模式之一,屬於結構型的設計模式,不過《JavaScript 設計模式學習手冊 第二版》這本書沒有特別介紹 Proxy 模式,這裡就整理我看 GoF 原文和網路相關資源的筆記~
在大型應用程式中,有時我們須控制對某些物件的存取,例如,一個複雜物件可能需要大量的資源來運算、初始化或處理資料,或者有時我們需要限制對特定資料的存取。在這些情況下,如果直接存取或使用這些物件可能導致效能問題或安全性問題。
以 GoF 書中提到的例子來說,假如我們有個文件編輯器的應用程式,使用者可能會嵌入一些大型圖像,而這些圖像需要花費很多資源才能載入,如果每次打開文件都立即載入所有圖像,會導致文件打開時變得非常緩慢,因此需要有個方式來避免直接存取、運算這些複雜圖像物件。
如何在不直接存取目標物件的狀況下,仍能有效的控制、管理或延遲對該物件的操作?如此可節省運算資源、提高應用程式效能,並避免敏感資訊被未授權者操作。
Proxy 中文可翻作代理,而代理的意思就像是一個中間人的角色,以生活化例子來說,如果某品牌廠商想找藝人合作宣傳商品,品牌方會聯絡藝人的經紀人而非直接聯絡藝人,那經紀人就扮演代理人的角色
GoF 在書中定義 Proxy 的目的是:「Provide a surrogate or placeholder for another object to control access to it.」。簡單來說我們不會直接存取和操作目標物件,而是改用一個代理來負責對目標物件的操作,這個代理可保護目標物件被直接操作或存取,也可以多一層篩選。
簡單示意圖說明沒有 Proxy 與有 Proxy 的差別:
圖 1 有無 Proxy 的示意圖(資料來源:自行繪製)
現在來看個簡單範例,假設現在有個物件 user,裡面有名字、年紀、居住地等資料。
const user = {
name: "Monica",
age: 24,
location: "Taipei",
interests: ["read comics", "sleep"]
};
我們可透過屬性名稱來取出對應的值,也可以指派新的值給該屬性。
console.log(user.name); // Monica
console.log(user.age); // 24
console.log(user.location); // Taipei
user.name = 'linnn';
user.age = 20;
console.log(user.name); // linnn
console.log(user.age); // 20
以上是一個很常見也是我們平常會存取和操作物件的方式。
但現在有個新需求,就是我們的使用者希望自己的年齡永遠 18 歲!🤣希望在每次存取 user.age
的時候都回傳 18,不管原本是多少,而如果要操作修改 age
的話,還是可以修改,但會印出訊息告訴你,該使用者永遠 18 歲👶。其餘屬性的存取和操作則保持不變。
這時我們就可以用 Proxy 來代理對 user 物件的存取和操作,那要如何實作 Proxy 呢?在 JavaScript 現有語法中已經有一個 Proxy 建構子,我們可直接呼叫 new Proxy
來建立 proxy 實例。
呼叫 Proxy 建構子時需要傳入兩個參數,第一個是 target
,第二個是 handler
:
const myProxy = new Proxy(target, handler);
target
傳入希望代理的目標物件,以上面範例來說就是 user
物件。handler
則傳入一個物件,這物件會定義 proxy 要做哪些額外的處理來管控對目標物件的操作,以上面範例來說,就是要加上管控年齡的相關邏輯。
這個 handler
物件內可以包含的方法如: get
、set
、has
、setPrototypeOf
...等,完整介紹可看文件,其中我們最常使用的就是 handler
的 get
和 set
方法。get
方法會在我們要存取目標物件屬性時被觸發執行,例如要存取 target.xxx
時,proxy 就會呼叫 get
,get
方法會收到 3 個參數,分別為 target
、property
和 receiver
:
target
: 要存取的目標物件property
: 要存取的屬性 (以上面舉例來說就是 xxx
)receiver
: 通常代表 proxy 本身(代表收到這個存取需求的)set
方法則是在我們要更改目標物件屬性時被觸發執行,例如要執行 target.xxx = 'newValue'
時,proxy 就會呼叫 set
,set
方法會收到 4 個參數,分別為 target
、property
、value
和 receiver
,target
、property
和 receiver
代表的意思和 get
的參數一樣,但 set
有多一個 value
參數,這代表的是賦予的新值,以前面舉例來說就是 'newValue'
。
proxy 收到存取和更改的需求時,除了在 get
和 set
方法內做些額外處理,還有最重要的是要實作這些存取和操作(總不能告訴代理人我要做這個,結果代理人做了額外的事情,卻沒做到我實際上希望他做的事吧...),所以 proxy 需要在 handler
內實際存取或操作目標物件,按照 get
和 set
收到的參數,我們可以在 get
方法內回傳 target[property]
來提供目標物件的屬性值,在 set
方法內執行 target[property] = value
來實際更改目標物件的屬性值。
不過通常我們會搭配使用 Reflect 來存取或操作目標物件,執行 Reflect.get(target, property, receiver)
就代表是存取目標物件的值,執行 Reflect.set(target, property, value, receiver)
則代表設定/更改目標物件的值。Reflect
的 get
和 set
方法參數都和 handler
的 get
和 set
方法參數一樣,照樣傳入即可。
以下是 handler
的程式碼範例,這個 handler 就可以在建立 proxy 實例時作為參數填入。
// ... 定義目標物件 target
const proxyHandler = {
get(target, property, receiver) {
// 一些額外處理邏輯
console.log(`你正在存取目標物件 ${JSON.stringify(target)} 的 ${property} 屬性`);
// 存取目標物件的值
return Reflect.get(target, property, receiver); // 補充,如果沒有特別需求,也可以不傳入 receiver 參數
}
set(target, property, value, receiver) {
// 一些額外處理邏輯
console.log(`你正在設定目標物件 ${JSON.stringify(target)} 的 ${property} 屬性,新的值為 ${value}`);
// 操作目標物件
return Reflect.set(target, property, value, receiver); // 補充,如果沒有特別需求,也可以不傳入 receiver 參數
}
}
const myProxy = new Proxy(target, proxyHandler);
介紹完 Proxy 基本使用方式後,回到我們的 user 來看如何加上 proxy 吧!
我們要先建立 userProxy
來負責代理對 user 的存取和操作,接著我們要存取或操作 user 時,都要透過 userProxy
來進行。
const user = {
name: "Monica",
age: 24,
location: "Taipei",
interests: ["read comics", "sleep"]
};
const userProxy = new Proxy(user, {
get(target, prop) {
if (prop === 'age') { // 如果要存取的屬性是年紀,就永遠回傳 18
return 18;
}
return Reflect.get(target, prop); // 使用 Reflect 存取目標物件屬性
},
set(target, prop, value) {
if (prop === 'age') { // 如果要修改的屬性是年紀,印出提示訊息
console.log('該使用者永遠 18 歲👶');
}
return Reflect.set(target, prop, value); // 使用 Reflect 修改目標物件屬性值
}
});
// 測試存取和修改
console.log(userProxy.age); // 18
userProxy.age = 30; // 該使用者永遠 18 歲👶
console.log(userProxy.age); // 18
console.log(userProxy.name); // Monica
userProxy.name = "linnn";
console.log(userProxy.name); // linnn
以上是一個簡單的 Proxy 應用範例,也介紹了 JavaScript 原生 Proxy 的使用方式~接著介紹 Proxy 的應用場景與案例。
Proxy 可應用的地方非常多,例如:
這在 GoF 書中又稱為 virtual proxy。如果在應用程式中有個肥大、運算成本高昂的物件時,但這個物件不需要隨時都在運行/啟動狀態,只有特定狀況才需要,就可利用 Proxy 讓它在需要的時候才要初始化。開頭情境所提的文件編輯器的圖像存取就是這個例子,我們可用 proxy 來延後對複雜圖像物件的初始化。
debounce
與 throttle
在前端應用中常見的 debounce
或 throttle
都可視為 virtual proxy 的一種。
我們將原始方法用 debounce
函式包住,改呼叫 debounce
函式包住的方法,這樣 debounce
函式就可作為 proxy 來幫我們延後呼叫原始的函式。簡單 debounce
範例如下,throttle
也是應用類似的 proxy 概念。
function debounce(fn, t) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn(...args);
}, t);
};
}
function myFunc(){
console.log('hello!')
}
const debounceMyFunc = debounce(myFunc, 1000);
// 改呼叫 debounceMyFunc
debounceMyFunc()
這在 GoF 書中又稱為 protection proxy。如果希望只有特定客戶端能存取目標物件時,就可使用 Proxy 來進行初步的篩選,只有符合特定條件的 Proxy 才會將請求傳遞給目標物件。
以網路服務來說,如果 client 送出請求,會先經過 proxy server,proxy 會判斷 client 的資料來決定 client 是否有權限可訪問這資源。
這也可應用在驗證(validation)上,當我們要更改物件屬性值時,proxy 可先檢查輸入值是否合法,例如如果要更改 user 的 age,那輸入的值就要是數值、不能是字串,如果是字串,那 proxy 就不會對目標物件操作。簡單示意在 proxy handler 可以這樣寫:
const proxyHandler = {
set: (obj, prop, value) => {
if (prop === "age" && typeof value !== "number") {
console.log(`age 只能是數值`);
} else {
Reflect.set(obj, prop, value);
}
}
}
此外,React 開發者應該對 Immer 函式庫不陌生,Immer 是一個用來簡化不可變(immutable)狀態操作的函式庫。它允許我們用 mutable 的方式來編寫代碼,但實際上保持狀態不可變。這讓 Immer 非常適合與 React 等需要處理 immutable 狀態的框架一起使用。
使用 Immer 時,我們會接收到一個 draft
參數,這個 draft
就是目前狀態(currentState)的代理物件(proxy)。我們可以直接修改 draft(例如:draft.age = 25
),但因為我們操作的是代理物件,原始狀態並不會被直接改變。Immer 會記錄我們對 draft 的所有修改,並根據這些修改生成下一個狀態(nextState),實現 immutable 狀態的更新。
以下為 Immer 官網示意圖。
圖 2 Immer 運作示意圖(資料來源:Immer 官網)
這在 GoF 書中又稱為 remote proxy。如果要操作或請求的服務位於遠端伺服器時,可由 proxy 來負責傳送請求,讓 proxy 負責與網路溝通的複雜細節,client 就不需自行處理。
在前端應用中很常會遇到 CORS (跨來源資源共享)問題,是因為發出請求的前端和擁有資源的後端在不同源(不同 origin),因此瀏覽器會擋住請求回來的資料。而這時我們就可用 proxy server 來代理這個請求,如果原本是前端 A 要向後端 B 發出請求,就改成前端 A 向 proxy sever 發出請求,proxy server 再向後端 B 請求,接著 proxy server 收到後端 B 回覆後再傳給前端 A。
proxy sever 收到後端資料要傳給前端時,就可再加上額外邏輯處理,也就是幫前端把 response 加上 Access-Control-Allow-Origin
這個 header。
以前第一次遇到 CORS 問題在查解法時,一直看到 proxy 但都十分模糊不知道這是什麼,剛好趁這次文章有理解 proxy 的意思了XD
關於 CORS 的詳細解說推薦大家閱讀:CORS 完全手冊(一):為什麼會發生 CORS 錯誤?、CORS 完全手冊(二):如何解決 CORS 問題?
另外,在目前工作中我接觸到 imgproxy 這服務,imgproxy 是一個處理圖片的服務,可透過 URL 參數來調整、縮放或優化圖片,imgproxy 會根據給定的參數處理圖片,並將圖片結果回傳給我們,這很方便我們生成各種大小的圖片,以適應前端不同大小的裝置。而 imgproxy 就可視為 remote proxy 的一種,因為它接收 client 端請求,處理遠端圖片並回傳結果,也簡化了 client 端的操作,讓 client 端只要發送請求即可,不需自行處理圖片。
又稱為 logging proxy,如果想要保留對目標物件的請求記錄時,就可使用 proxy,在每個請求傳遞給目標物件前先記錄請求的資訊。
又稱為 caching proxy,如果某個請求需要很久的花費時間,就可快取這個請求結果。在 proxy 中儲存相同請求參數的請求結果,如果快取中有儲存該結果,就直接回傳快取的;如果沒有,才發出真正的請求。在 用 JavaScript 玩轉設計模式 | 替你處理行為的 Proxy Pattern(代理者模式) 有提及這種緩存 API 請求的程式碼。
CDN(Content Delivery Network,內容傳遞網路)其實也是應用了 Proxy 的概念,他快取了請求結果,避免多次向遙遠的伺服器請求。
useMemo
而在 React 中,useMemo
這個 hook 其實也可視為快取代理,因為它快取了複雜運算的結果,只有在 dependencies 不同時才會重新運算。
以 Proxy 作為解決方案優點如下:
以 Proxy 作為解決方案缺點如下: